iT邦幫忙

2026 iThome 鐵人賽

DAY 1
0

我是一名剛升大一新鮮人,暑假期間,無意間看到了一篇有關於自己打造 Agent 的文章,我也想來自己試試看!

https://github.com/Windy3f3f3f3f/claude-code-from-scratch 啟發。

因為我想要練習 Python 的能力,基本上每一行都是我手打出來的,當然!也有讓 AI 給我不少引導。
這專案大概是從 7 月開始陸陸續續進行,也因為沒有用 vibe-coding,所以推進得非常慢,本來打算一次整理好全部內容再開始發文,但是參賽時間迫在眉睫啦,哈哈!
所以現在,我也不確定整份專案會完成到什麼程度,但我可以跟各位保證的是已經有「可以完整使用」的程度了(可以先去 https://github.com/LeeMatthewCat/code-notes 看看),發佈這篇的當下,我已經在撰寫 Day - 18 的內容,目前已經有的功能是:

  1. 工具調用能力(讀文件、寫文件、跑終端、讀網頁)。
  2. 注入 system prompt。
  3. 簡單的指令功能。
  4. CLI 介面。

而還剩 10 多篇內容,我目前還想達成的是:

  1. Agent 的設定功能。
  2. 多模態處理(讀取圖片)。
  3. 上下文壓縮、記憶管理。
  4. 多 Agent 調度。
    等等的功能,但我也不知道最終我能完成哪些,哈哈。

然後,剛剛有說過我想練習 Python,簡單來說,我才剛學 Python 不久,所以可能有些地方會有錯,或是有比我更好的寫法,還請大家指出

如果你也是新手,可以去看看我和 AI 一起合力編輯的筆記 https://github.com/LeeMatthewCat/code-notes ,文章裡面有出現的函數或功能大部分在這份筆記裡都有。

我文章中程式碼的部分是拆成一部分一部分講解的,我有盡量把前後的部分寫清楚,不至於看不出來這段在哪,想看更完整的部分可以到 GitHub(有些部分寫得可能跟文章不大一樣,自己斟酌一下)。

那麼,我們開始吧!


首先,試著調用模型

我們先用最簡單的方式來讓模型回答我們的問題

# src/meowgent/agent.py
import ollama

if __name__ == "__main__":
    user_input = input("輸入問題:")  # 獲取用戶輸入

    response = ollama.chat(
        model="qwen3.8",  # 請替換為本地實際下載的模型名稱
        messages=[
            {
                "role": "user",
                "content": user_input,
            }
        ],
    )

    print(response.message.content)  # 印出模型回傳的文字

如此一來 uv run src/meowgent/agent.py 就能調用模型來回答問題了!

確保 ollama 軟體處於「開啟」狀態,
後續會做 ollama 啟動與關閉的管理(ollama_manager.py 中)。

接著,我們來試試對於「多輪對話」的支援:

什麼是多輪對話?
模型其實沒有實質意義上的記憶能力,他的記憶來自於在每一次調用時都把「先前的對話傳入」,這就是所謂的多輪對話!

而我們需要有一個紀錄多輪對話的串列 - history_messages

# src/meowgent/agent.py
import ollama

if __name__ == "__main__":
    
    history_messages = [] # 初始化多輪對話紀錄
    
    while True: # 對話迴圈
        
        user_input = input("輸入問題:")
        
        history_messages.append(
            {
                "role": "user",
                "content": user_input
            }
        ) # 紀錄下使用者輸入
            
        
        response = ollama.chat(
            model="qwen3.8",
            messages=history_messages
        )
        
        print(response.message.content)
    
        history_messages.append(
            {
                "role": "assistant",
                "content": response.message.content
            }
        ) # 紀錄下模型的回答,接著重新開始下一個問題

專案級別的分工

目前我們只使用了 agent.py 一個檔案,但接下來,我們會有越來越多的功能。
一般來說,專案中會在各個檔案中做出分工:

src/meowgent/
├── providers
│   ├── __init__.py
│   ├── base.py
│   └── ollama_provider.py
│
└── agent.py

主要的程式入口我們放在 agent.py,模型調用放在 ollama_provider.pybase.py 負責做規格的定義。

# src/meowgent/providers/__init__.py
from .base import ToolCall, LLMResponse, StreamChunk, LLMProvider
from .ollama_provider import OllamaProvider

專案中還有做對其他模型提供方式的支援,篇幅原因,本文只討論 ollama 的部分。

規格定義

接著我們使用 @dataclass 自行定義 StreamChunk 類別,明確將輸出拆分為「推理思考」與「最終回答」兩大部分:

至於為什麼要區分出推理及回答,是因為後期我會將兩者內容做不同效果的顯示,所以就先將它們區分開了。

# src/meowgent/providers/base.py
...

@dataclass
class StreamChunk:
    """ 統一串流(流式輸出)協定 """
    thinking_chunk: Optional[str] = None
    content_chunk: Optional[str] = None

接著我們定義 LLMProvider 類別並繼承 ABC 來規範模型提供者的行為,強制 provider 實作 stream_generate 用來做輸出。
同時我們一邊來實作串流輸出,也就是模型不會等到輸出完所有文字才回傳,而是一部分一部分(chunk)的做回傳。

# src/meowgent/providers/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Dict, List, Any, Iterator, Literal

class LLMProvider(ABC):
    def __init__(
            self,
            model_name: str
    ):
        self.model_name = model_name
    
    @abstractmethod
    def stream_generate(
        self,
        history_messages: list
    ) -> Iterator[StreamChunk]: # 因為串流輸出,回傳的是迭代物件
        ...

provider 的製作

provider 要做到的點只有:

  1. 接收輸入進來的多輪紀錄 history_messages
  2. 將多輪紀錄傳給模型。
  3. 將模型的串流回傳給 agent.py

建立 OllamaProvider 類別,先設定好使用模型(由 agent.py 傳入):
super() 繼承 LLMProvider 提到的 model_name

# src/meowgent/providers/ollama_provider.py
from .base import LLMProvider, StreamChunk
from typing import Iterator, Optional
import ollama

class OllamaProvider(LLMProvider):
    def __init__(self, model_name: str):
        super().__init__(model_name=model_name)
        
    def stream_generate(
        self,
        history_messages: list
    ) -> Iterator[StreamChunk]: # base.py 中規定的實作
        ...

接著是將對話傳入給模型,這裡我們把 stream=True 加上,來開啟串流輸出

如果沒有用串流輸出,模型的回答會在「全部完成後」才輸出,體感上響應會慢很多。

# src/meowgent/providers/ollama_provider.py
...
class OllamaProvider(...):
    ...
    def stream_generate(...) -> ...:
        
        response = ollama.chat(
            model=self.model_name,
            messages=history_messages,
            stream=True # 流式輸出文字
        )

接著 ollama_provider.py 下方要進行對於串流回傳的處理,在這之前,我們回頭看一下 base.py 說的「stream_generate() 回傳的是裝著 StreamChunk 型別(自訂的型別)的可迭代物件」,
也就是說,ollama_provider.py 要用 yield 的方式一 chunk 一 chunk 的回傳片段給 agent.py
我們先用 for 把 chunk 取出,接著 yield 傳出:

# src/meowgent/providers/ollama_provider.py
...
class OllamaProvider(...):
    def stream_generate(...) -> ...:
        ...
    
        for chunk in response:
            thinking = getattr(chunk.message, "thinking", None)
            # 用 getattr 防止模型沒有推理功能(沒有 message.thinking 屬性)
            
            content = chunk.message.content if chunk.message.content else None
            # 為空字串則 None
            
            yield StreamChunk(thinking_chunk=thinking, content_chunk=content)

模型的回答放在 chunk.message.content,推理放在 chunk.message.thinking

如此一來 ollama_provider.py 的部分就完成啦!接著,我們來進行 agent.py


核心大腦 - agent.py

我們已經把模型的請求部分放到了 ollama_provider.py 裡,
所以,現在 agent.py 只需關注:

  1. 模型的選擇
  2. 多輪對話的紀錄(包括加入使用者的訊息以及模型的回答)
  3. live 更新串流對話

後期會把終端的輸入輸出移出到 src/meowgent/cli 之中

先做資訊的初始化:

# src/meowgent/agent.py
from providers import OllamaProvider
from rich.console import Console
from rich.markdown import Markdown
from rich.live import Live
from typing import Iterator, Optional

if __name__ == "__main__":
    console = Console() # 初始化 rich 終端
    
    model_name = "qwen3.8"
    provider = OllamaProvider(model_name)
    
    history_messages = []

接著一樣進入到「對話迴圈」的部分,

# src/meowgent/agent.py
if __name__ == "__main__":

    ...

    while True: # 對話迴圈
        
        user_input = input("輸入問題:")
        
        history_messages.append(
            {
                "role": "user",
                "content": user_input
            }
        )
        
        response = provider.stream_generate(
            history_messages=history_messages
        )
        # 調用 ollama_provider

再來要進行印出串流的實作,我們採用 live 的方案,用 thinking_full_textresult_full_text 不斷收集 response.chunk 吐出的新文字,並整段進行更新:

舉例來說,第一個 chunk 吐出「你」的回答,我們放入 result_full_text 並用 live 輸出 result_full_text,接著吐出「好」,再次放入並輸出 result_full_text,以此類推。

# src/meowgent/agent.py
if __name__ == "__main__":
    ...
    
    while True:
        ...
        
        thinking_full_text = ""
        result_full_text = ""
        
        with Live(
		    console=console,
            refresh_per_second=10,
            vertical_overflow="visible"
	    ) as live:
            for chunk in response:
            
                if chunk.thinking_chunk: # 推理
                    thinking_full_text += chunk.thinking_chunk
                    live.update(Markdown(
                        thinking_full_text,
                        style="dim"
                    ))
                    # 用灰色來跟模型回答做出區別
                    
                if chunk.content_chunk: # 回答
                    result_full_text += chunk.content_chunk
                    live.update(Markdown(result_full_text))
                
        history_messages.append(
            {
                "role": "assistant",
                "content": result_full_text
            }
        ) # 最後要再把回答加回多輪紀錄

vertical_overflow 目前設為 "visible" 也就是當輸出內容超過畫面時會自動把畫面向下滾,但是他會跟我們人去手動「滾動頁面」發生衝突,但這個問題,
我們未來在 CLI 章節再去做解決!


今天這篇,我們做出來的東西其實還不能稱作是一個 Agent,它還缺乏一個非常重要的能力 -「工具調用」。
下一篇,我們來進行這個部分!


下一篇
Day 2 - 加入工具吧 - 上
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言